﻿using System;
using System.Collections.Generic;
using System.Configuration.Provider;
using System.Xml;
using TallComponents.PDF;
using TallComponents.Web.Pdf.Resources;
using System.IO;
using System.Web;
using System.Security.Cryptography;
using Document = TallComponents.Web.Pdf.Document;
using TallComponents.PDF.Rasterizer;

namespace TallComponents.Web.Storage
{
   /// <summary>
   /// Storage provider that uses the file system to store PDF documents.
   /// </summary>
   public class FileSystemStorageProvider : PdfStorageProvider
   {
      /// <summary>
      /// Initializes the provider.
      /// </summary>
      /// <param name="name">The friendly name of the provider.</param>
      /// <param name="config">A collection of the name/value pairs representing the provider-specific
      /// attributes specified in the configuration for this provider.</param>
      /// <exception cref="T:System.ArgumentNullException">The name of the provider is null.</exception>
      /// <exception cref="T:System.ArgumentException">The name of the provider has a length of zero.</exception>
      /// <exception cref="T:System.InvalidOperationException">An attempt is made to call
      /// <see cref="M:System.Configuration.Provider.ProviderBase.Initialize(System.String,System.Collections.Specialized.NameValueCollection)"/>
      /// on a provider after the provider has already been initialized.
      /// </exception>
      public override void Initialize( string name, System.Collections.Specialized.NameValueCollection config )
      {
         base.Initialize( name, config );

         if( string.IsNullOrEmpty( config["basePath"] ) )
            throw new ProviderException( string.Format( Text.Config_missing_required_property, "basePath", name ) );

         string trimmed = config[ "basePath" ].Trim();

         if ( trimmed.Length == 0 )
            throw new ProviderException( string.Format( Text.Config_invalid_property_value, "basePath", config[ "basePath" ] ) );

         if ( !string.IsNullOrEmpty( config[ "enableSubfolderAccess" ] ) )
         {
            EnableSubfolderAccess = Convert.ToBoolean( config[ "enableSubfolderAccess" ] );
         }
         else
         {
            EnableSubfolderAccess = true;
         }

         BasePath = trimmed;
      }

      /// <summary>
      /// Stores a file.
      /// </summary>
      /// <param name="fileName">Name of the file.</param>
      /// <param name="data">A seekable stream containing the file data.</param>
      /// <remarks>When implementing a custom provider based on this provider, override this method to implement custom logic for storing PDF files in 
      /// the storage medium.</remarks>
      /// <returns>The unique key for the file.</returns>
      /// <exception cref="ArgumentNullException">Occurs when <paramref name="fileName"/> or <paramref name="data"/> is <c>null</c>.</exception>
      /// <exception cref="ArgumentException">Occurs when <paramref name="fileName"/> is an empty string or <paramref name="data"/> does not hold a valid PDF document.</exception>
      public override string StoreFile( string fileName, Stream data )
      {
         if ( null == data )
            throw new ArgumentNullException( "data" );

         if ( null == fileName )
            throw new ArgumentNullException( "fileName" );

         string trimmed = fileName.Trim();

         if ( trimmed.Length == 0 )
            throw new ArgumentException( TallComponents.Web.Pdf.Resources.Text.PdfStorageProvider_Empty_FileName_Not_Allowed, "fileName" );

         string path = Path.Combine( CheckedBasePath, fileName ) ;
         string key = MakeStorageKey( path);
         if (!_fileNamesByKey.ContainsKey(key))
            _fileNamesByKey.Add(key, path);

         try
         {
            new Document( data, trimmed, key ); // this validates the document
         }
         catch ( TallComponents.PDF.WrongPasswordException )
         {
            // ignore password exceptions; the document is valid that's what counts
         }
         catch ( Exception ex )
         {
            throw new ArgumentException( TallComponents.Web.Pdf.Resources.Text.PdfStorageProvider_Invalid_Document, "data", ex );
         }

         StoreFileInternal( trimmed, data );

         return key;
      }
      

      /// <summary>
      /// Stores a file.
      /// </summary>
      /// <param name="fileName">Name of the file.</param>
      /// <param name="data">A seekable stream containing the file data.</param>
      /// <returns>The unique key for the file.</returns>
      private string StoreFileInternal( string fileName, Stream data )
      {
         if ( null == data )
            throw new ArgumentNullException( "data" );

         if ( null == fileName )
            throw new ArgumentNullException( "fileName" );

         string trimmed = fileName.Trim();

         if ( trimmed.Length == 0 )
            throw new ArgumentException( TallComponents.Web.Pdf.Resources.Text.PdfStorageProvider_Empty_FileName_Not_Allowed, "fileName" );

         if ( data.CanSeek )
            data.Seek( 0, SeekOrigin.Begin ); // rewind stream

         string fullPath = Path.Combine( CheckedBasePath, trimmed );
         using ( var stream = new FileStream( fullPath, FileMode.Create, FileAccess.ReadWrite ) )
         {
            int read = 0;
            byte[] buffer = new byte[ 2048 ];
            do
            {
               read = data.Read( buffer, 0, buffer.Length );
               stream.Write( buffer, 0, read );
            } while ( read > 0 );
         }

         string storageKey = MakeStorageKey( fullPath );
         if (!_fileNamesByKey.ContainsKey(storageKey))
            _fileNamesByKey.Add(storageKey, fullPath);

         return storageKey;
      }

      /// <summary>
      /// Gets the stream identified by the <paramref name="uniqueKey"/>.
      /// </summary>
      /// <param name="uniqueKey">The unique key for the file to retrieve.</param>
      /// <param name="fullPath">Receives the full path for the document or an empty string if the document is not found.</param>
      /// <remarks>When implementing a custom provider based on this provider, override this method to implement custom logic for
      /// accessing PDF file data.</remarks>
      /// <returns>
      /// A seekable stream containing the file's contents or <c>null</c> if the file is (no longer) available.
      /// </returns>
      protected virtual Stream GetStream( string uniqueKey, out string fullPath )
      {
         if ( TryResolveFileName( uniqueKey, out fullPath ) )
         {
            return new FileStream( fullPath, FileMode.Open, FileAccess.Read );
         }
         fullPath = string.Empty;
         return null;
      }

      /// <summary>
      /// Gets information on all the documents in storage.
      /// </summary>
      /// <returns>
      /// A list of <see cref="Document"/> objects, describing all PDF documents managed by this provider.
      /// </returns>
      public override IList<Document> GetDocuments()
      {
         var result = new List<Document>();

         foreach (var item in GetFiles( CheckedBasePath ))
         {
            if ( string.Equals( System.IO.Path.GetExtension( item.Value ), ".pdf", StringComparison.OrdinalIgnoreCase ) )
            {
               using (var stream = new FileStream( item.Value, FileMode.Open, FileAccess.Read ))
               {
                  result.Add( new Document( stream, Path.GetFileName( item.Value ), item.Key, GetPassword( item.Value ) ) );
               }
            }
         }

         return result;
      }

      /// <summary>
      /// Gets information about a PDF document.
      /// </summary>
      /// <param name="key">The unique key.</param>
      /// <returns>A <see cref="Document"/> object describing the document with the 
      /// specified <paramref name="key"/> or <c>null</c> if the key is not valid or 
      /// associated with an invalid document.</returns>
      public override Document GetDocument( string key )
      {
         Document result = null;
         if ( !string.IsNullOrEmpty( key ) )
         {
            // Key may be a file name or a storage key
            string path = Path.Combine( CheckedBasePath, key );
            if ( File.Exists( Path.Combine( CheckedBasePath, key ) ) )
            {
               var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
               string storageKey = MakeStorageKey(path);
               if (!_fileNamesByKey.ContainsKey(storageKey))
                  _fileNamesByKey.Add(storageKey, path);
               result = new Document(file, Path.GetFileName(path), storageKey, GetPassword(path));
            }
            else
            {
               string fullPath;
               var stream = GetStream(key, out fullPath);
                  result = new Document(stream, Path.GetFileName(fullPath), key, GetPassword(fullPath));
               }
            }
         return result;
      }

      /// <summary>
      /// Gets the base path.
      /// </summary>
      /// <remarks>The base path is either a virtual path to a folder within the web application (for example <c>~/documents</c>) 
      /// or an absolute path to a folder somewhere on the server or a UNC path.
      /// <para>This is setting is configured in web.config. Check <see cref="PdfStorageProviderSection"/> for more information.</para></remarks>
      /// <value>The base path.</value>
      public string BasePath
      {
         get;
         private set;
      }

      /// <summary>
      /// Gets a value indicating whether subfolders are accessible.
      /// </summary>
      /// <remarks>This is enabled by default. Set <c>enableSubfolderAccess</c> to <c>false</c> in the provider configuration in web.config to disable this feature.
      /// <para>Check <see cref="PdfStorageProviderSection"/> for more information on provider configuration.</para></remarks>
      /// <value>
      /// 	<c>true</c> if subfolders are accessible; otherwise, <c>false</c>.
      /// </value>
      public bool EnableSubfolderAccess { get; private set; }

      private string CheckedBasePath
      {
         get
         {
            string result = AbsolutePath( BasePath );
            if ( !Directory.Exists( result ) )
               throw new InvalidOperationException( string.Format( "Path '{0}' resolves to '{1}', which does not exist or is not a directory.", BasePath, result ) );

            return result;
         }
      }

      /// <summary>
      /// Returns the file system path represented by the specified <paramref name="virtualPath"/>.
      /// </summary>
      /// <param name="virtualPath">The virtual path.</param>
      /// <returns>An absolute file system path.</returns>
      private string AbsolutePath( string virtualPath )
      {
         if ( null == virtualPath )
            throw new ArgumentNullException( "virtualPath" );

         string result = virtualPath.Trim();

         if ( result.Length == 0 )
            throw new ArgumentException( Text.FileSystemStorageProvider_Virtual_Path_Must_Not_Be_Empty, "virtualPath" );
                         
         if ( virtualPath[ 0 ] == '~' || virtualPath[ 0 ] == '/' )
         {
            result = Context.Server.MapPath( result );
         }
         else if ( !Path.IsPathRooted( result ) )
         {
            result = Path.GetFullPath( result );
         }

         return result;
      }

      /// <summary>
      /// Tries to resolve the file name for the specified storage key.
      /// </summary>
      /// <remarks>This method performs a file system based lookup of the PDF document. If a file is found that matches the key 
      /// the method will return true. There is however no guarantee that the document is valid. Call <see cref="GetDocument(string)"/> to validate the document.</remarks>
      /// <param name="key">The storage key to resolve.</param>
      /// <param name="filename">Contains the absolute path of the PDF document if the key was successfully resolved.</param>
      /// <returns>
      /// A value indicating whether the key was successfully resolve file name.
      /// </returns>
      public bool TryResolveFileName( string key, out string filename )
      {
         if( string.IsNullOrEmpty( key ) )
         {
            filename = null;
         }
         else if( !_fileNamesByKey.TryGetValue( key, out filename ))
         {
            filename = ResolveFileName( CheckedBasePath, key );
         }
         return (null != filename);
      }

      private string ResolveFileName( string basePath, string key )
      {
         foreach ( string file in Directory.GetFiles( basePath ) )
         {
            var storageKey = MakeStorageKey( file );
            if ( !_fileNamesByKey.ContainsKey( storageKey ) )
            {
               _fileNamesByKey.Add( storageKey, file );
            }
            if ( storageKey.Equals( key ) )
               return file;
         }
         if ( EnableSubfolderAccess )
         {
            foreach ( string directory in Directory.GetDirectories( basePath ) )
            {
               var result = ResolveFileName( directory, key );
               if( null != result )
                  return result;
            }
         }
         return null;
      }

      private IDictionary<string, string> GetFiles( string path )
      {
         var keys =  new Dictionary<string,string>();
         foreach ( var file in Directory.GetFiles( path ) )
         {
            var key = MakeStorageKey( file );
            keys.Add( key, file );
            if( !_fileNamesByKey.ContainsKey( key ))
            {
               _fileNamesByKey.Add( key, file );
            }
         }
         return keys;
      }

      /// <summary>
      /// Makes the storage key.
      /// </summary>
      /// <param name="path">The full path to the file.</param>
      /// <returns>A unique storage key for the file.</returns>
      /// <remarks>Override this method in a derived class to implement a custom storage key scheme. 
      /// <para>The storage key is publicly visible so for security reasons make sure it does not expose sensitive information
      /// like actual file paths on the server.</para></remarks>
      protected virtual string MakeStorageKey( string path )
      {
         // Normalize slashes in path
         if( path.Contains( "/" ) )
         {
            path = path.Replace( '/', '\\' );
         }

         if( null == _md5 )
         {
            _md5 = MD5.Create();
         }

         path = path.ToLower();

         return new Guid( _md5.ComputeHash( System.Text.Encoding.UTF8.GetBytes( path ) ) ).ToString();
      }

      /// <summary>
      /// Gets the password for the specified document.
      /// </summary>
      /// <remarks>FileSystemStorageProviders supports getting passwords from an XML file. This file looks like this: 
      /// <example>
      ///   <code lang="xml">
      /// &lt;files>
      ///   &lt;file path="annual_report.pdf" password="ManagersOnly"/>
      ///   &lt;file path="documents\mydocument.pdf" password="TopSecret123"/>
      /// &lt;/files>
      ///   </code>
      /// </example>
      /// The file must be located in the document storage folder.
      /// </remarks>
      /// <param name="path">The path.</param>
      /// <returns>The password or <c>null</c> if not found.</returns>
      protected virtual string GetPassword( string  path )
      {
         var passwordsFile = Path.Combine( CheckedBasePath, "passwords.xml" );
         if ( !string.IsNullOrEmpty( path ) && System.IO.File.Exists( passwordsFile ))
         {
            try
            {
               var doc = new XmlDocument();
               doc.Load( passwordsFile );

               foreach( XmlElement fileElement in doc.DocumentElement.GetElementsByTagName( "file", string.Empty ) )
               {
                  var pathAttribute = fileElement.GetAttribute( "path" );
                  if ( !string.IsNullOrEmpty( pathAttribute ) && ( path.EndsWith( pathAttribute ) 
                     || string.Equals( path, pathAttribute, StringComparison.OrdinalIgnoreCase ) ) )
                  {
                     var passwordAttribute = fileElement.GetAttribute( "password" );
                     if ( !string.IsNullOrEmpty( passwordAttribute ) )
                     {
                        return passwordAttribute;
                     }
                  }
               }
            }
            catch (Exception)
            {
               // ignore errors
            }
         }
         return null;
      }

#if NET35 || NET40
      /// <summary>
      /// Gets the http context.
      /// </summary>
      /// <value>The context.</value>
      protected virtual HttpContextBase Context
      {
         get
         {
            if ( null == HttpContext.Current )
               throw new InvalidOperationException( Text.FileSystemStorageProvider_not_available_in_this_context );

            return new HttpContextWrapper( HttpContext.Current );
         }
      }
#else
      private HttpContext Context
      {
         get
         {
            if ( null == HttpContext.Current )
               throw new InvalidOperationException( Text.FileSystemStorageProvider_not_available_in_this_context );

            return HttpContext.Current;
         }
      }
#endif

      private MD5 _md5;
      // Cache for StorageKey / Filename translation
      private readonly IDictionary<string,string> _fileNamesByKey = new Dictionary<string, string>();
   }
}
